Skip to content

Retain UI phase items from frame to frame, and consolidate the UI queuing systems into one. - #25290

Closed
pcwalton wants to merge 7 commits into
bevyengine:mainfrom
pcwalton:retained-ui-queuing-staging
Closed

Retain UI phase items from frame to frame, and consolidate the UI queuing systems into one.#25290
pcwalton wants to merge 7 commits into
bevyengine:mainfrom
pcwalton:retained-ui-queuing-staging

Conversation

@pcwalton

@pcwalton pcwalton commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Although PR #24893 added retention for UI render world instances themselves, in order to avoid re-extracting them from the main world ECS every frame, we still recreate the TransparentUi phase items every frame via add_transient(), which additionally removes them from the phase at the end of every frame. This is a significant CPU time sink and isn't the preferred pattern in Bevy nowadays.

This commit makes the UI-related phase items retained just as 3D meshes are. All queue_ methods in bevy_ui_render have been updated to walk the list of changed and removed meshes and update elements in the SortedRenderPhase only as necessary. The calls to add_transient() have been removed in favor of the more modern add_retained().

Additionally, all the custom queuing systems have been consolidated into a single generic system, queue_ui_items. The resources that hold extracted UI items have likewise been consolidated into a generic UiRenderObjects resource. The behavior specific to each individual item type (normal UI nodes, box shadows, gradients, etc.) has been factored into a trait named UiRenderObject. This has resulted in dramatic simplifications throughout UI rendering. See the documentation for more information.

On many_buttons, this PR reduces the median frame time from 36.95 ms to 22.78 ms, or 27 FPS to 44 FPS. The queue_uinodes system has gone from 6.21 ms/frame to 15.2 μs/frame, a 409× speedup. And, because the Rust standard library's sorting algorithm is good at sorting data that's close to already sorted, the sort_phase_system time decreases from 5.17 ms/frame to 1.29 ms/frame, a 4.01× speedup.

Screenshot 2026-08-03 124336 Screenshot 2026-08-03 124508 Screenshot 2026-08-03 124519

queuing systems into one.

Although PR bevyengine#24893 added retention for UI render world instances
themselves, in order to avoid re-extracting them from the main world ECS
every frame, we still recreate the `TransparentUi` phase items every
frame via `add_transient()`, which additionally removes them from the
phase at the end of every frame. This is a significant CPU time sink and
isn't the preferred pattern in Bevy nowadays.

This commit makes the UI-related phase items retained just as 3D meshes
are. All `queue_` methods in `bevy_ui_render` have been updated to walk
the list of changed and removed meshes and update elements in the
`SortedRenderPhase` only as necessary. The calls to `add_transient()`
have been removed in favor of the more modern `add_retained()`.

Additionally, all the custom queuing systems have been consolidated into
a single generic system, `queue_ui_items`. The resources that hold
extracted UI items have likewise been consolidated into a generic
`UiRenderObjects` resource. The behavior specific to each individual
item type (normal UI nodes, box shadows, gradients, etc.) has been
factored into a trait named `UiRenderObject`. This has resulted in
dramatic simplifications throughout UI rendering. See the documentation
for more information.

On `many_buttons`, this PR reduces the median frame time from 36.95 ms
to 22.78 ms, or 27 FPS to 44 FPS. The `queue_uinodes` system has gone
from 6.21 ms/frame to 15.2 μs/frame, a 409× speedup. And, because the
Rust standard library's sorting algorithm is good at sorting data that's
close to already sorted, the `sort_phase_system` time decreases from
5.17 ms/frame to 1.29 ms/frame, a 4.01× speedup.
@pcwalton
pcwalton requested a review from ickshonpe August 4, 2026 05:04
@pcwalton pcwalton added C-Performance A change motivated by improving speed, memory usage or compile times A-Rendering Drawing game state to the screen labels Aug 4, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in Rendering Aug 4, 2026
@pcwalton pcwalton added the A-UI Graphical user interfaces, styles, layouts, and widgets label Aug 4, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in UI Aug 4, 2026
@pcwalton pcwalton added the S-Needs-Review Needs reviewer attention (from anyone!) to move forward label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-25290

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@ickshonpe ickshonpe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, everything looks really good.

There might be some ideas from #25289 that the UiRenderObjects won't map to, I think. For instance with debug outlines, it uses a MainEntityHashMap<ExtractedDebugOutline> map. Because it's just a stack of lines, it only needs to be a single phase item with one representative render entity. That PR isn't as well thought out as this one though, maybe there's some way to make it work with UiRenderObject. And these changes don't force us to use the UiRenderObjects API in every case if we need some flexibility.

Mostly everything is very simple and obviously correct. I found one bug: it seems like renderable objects, apart those extracted into ExtractedUiNodes, aren't drawn unless they are reupdated after spawning.

The gradients example makes it clear:

cargo run --example gradients
Image

The static gradients on the left aren't visible, only the animated nodes on the right are rendered. Pressing the "previous" or "next" buttons updates the gradients, then they become visible and remain visible.

Similarly, with:

cargo run --example box_shadow --features="bevy_feathers"

Initially the shadow is missing. But after changing any of the options in the menu, the shadow appears and then remains visible.

pipeline_key_builder: E::create_view_pipeline_key_builder(pipeline_key_builder_item),
});
}
}

@ickshonpe ickshonpe Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lib module is getting a bit big, this could be moved into its own module. Either that or we could have a uinode module and move lib's extract_* and prepare_uinodes there, along with all the associated types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want me to do that in this PR? I usually try to keep refactorings in separate PRs to make reviewability easier and so that if this PR gets reverted for whatever reason the refactoring will remain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, I have no problem with moving the code I added into its own module, but moving the code I added plus additional pre-existing code into a module seems like it'd make this PR noisy. I'm happy to do it in a follow-up though.

@ickshonpe ickshonpe Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's sensible, just move the new code for now I think. It'll make it much easier for a second reviewer, once one shows up.

Comment thread crates/bevy_ui_render/src/lib.rs Outdated
.ok()
.and_then(|default_camera_view| {
let view = extracted_views.get(default_camera_view.0).ok()?;
let pipeline_key_builder = render_views.get(default_camera_view.0).ok()?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per camera view components like BoxShadowSamples and UiAntiAlias are stored on the current camera entity, not the UI view entity pointed at by UiCameraView:

Suggested change
let pipeline_key_builder = render_views.get(default_camera_view.0).ok()?;
let pipeline_key_builder = render_views.get(this_camera_entity).ok()?;

You can adjust the shadow samples on the box_shadow example to test when this is working.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, there is a second bug I just realised I think. There is no change detection on these query parameters. In the box_shadow example again, the shadow doesn't update on changing the shadow samples. One of the other parameters has to be changed to trigger an update to see the result of the shadow samples change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, this looks fixed now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, checked it with the change detection UI testbed scene from #25299 and everything seems to work now.

@ickshonpe ickshonpe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ui_material example doesn't display the material node. Which is strange because it's animated, so it shouldn't be the not visible until second update bug.

@ickshonpe

ickshonpe commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

It is the same bug, changes to the material asset don't trigger a second update. It becomes visible if you change the window size.

.get_mut(&main_entity)
.iter_mut()
.flat_map(|(_, gradients)| gradients.drain(..))
// If there were any previous gradients for this entity, despawn them

@ickshonpe ickshonpe Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the problem: If an object already exists in the objects list, it is removed and added to changed. But there is no mechanism to add an object to the changed list on the frame it is spawned. Phase items are added from the changed list, so it doesn't get queued for rendering. On a reupdate though, the object is present in the objects list, so then it can be removed, added to the changed list, and then queued correctly.

@pcwalton pcwalton Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I fixed this by updating all places that manually push things into the objects table to instead call a method add that also adds them to the changed list. Note that this caused some awkwardness in that we have to extract the keys (changed entities) from the changed table in some places so that we aren't mutating a table we're iterating over.

Otherwise the `button` example puts the shadow in the wrong place.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-25290

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-25290

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-25290

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@pcwalton

pcwalton commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

This is very strange, as that part of the UI testbed works locally!

@pcwalton

Copy link
Copy Markdown
Contributor Author

Phew, the problem with the testbed seems to be what I thought it was: the queuing can happen before the material finishes loading, so we manage to place the object in changed, but we fail to actually queue it because we can't generate the pipeline key until the material finishes loading. In that case we have to save it so that we can retry the queuing next frame even if the main world entity didn't change.

@pcwalton
pcwalton requested a review from ickshonpe August 10, 2026 03:57
@pcwalton

Copy link
Copy Markdown
Contributor Author

This should address all review comments (except for the refactoring one). Note that the PR is a lot bigger now, because I had to change all the extraction code to properly add new UI nodes to changed (via a helper method).

@ickshonpe

ickshonpe commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Phew, the problem with the testbed seems to be what I thought it was: the queuing can happen before the material finishes loading, so we manage to place the object in changed, but we fail to actually queue it because we can't generate the pipeline key until the material finishes loading. In that case we have to save it so that we can retry the queuing next frame even if the main world entity didn't change.

Maybe we can just remove these pipeline keys? I was concerned with the size of the per data vertex when I wrote these shaders, but that seems really naive now. It's not like users commonly draw thousands of gradients or shadows. And with instanced rate, it wouldn't be sent per vertex anyway.

@ickshonpe

Copy link
Copy Markdown
Contributor

I don't mean to do it here though, removing the pipeline keys here would be out of the scope of this PR for sure.

let extracted_uinodes = extracted_uinodes.into_inner();
let mut camera_mapper = camera_map.get_mapper();

changed_entities.extend(extracted_uinodes.changed.keys().copied());

@ickshonpe ickshonpe Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This extend and drain pattern looks wrong to me. There shouldn't be any need for a set as the keys are already unique, could just be a Vec<MainEntity>. Also does each extraction need its own local copy? It looks like it could just be kept in a resource, that's cleared and extended once per frame in extract_uinode_changes.

@ickshonpe ickshonpe Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah with a single global list on a full reupdate, it's about 30% improvement on the extract schedule and 8% overall:

image

https://github.com/ickshonpe/bevy/tree/25290-extracted-changes-vec

@ickshonpe ickshonpe Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As well if the changed list is ordered by stack index, then the extract systems will extract the changed node data in z order. It would need significant further changes to take advantage of the sorted list though, I think.

)>,
>,
camera_map: Extract<UiCameraMap>,
mut changed_entities: Local<MainEntityHashSet>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mut changed_entities: Local<MainEntityHashSet>,
mut changed_entities: Local<Vec<MainEntity>>,

@ickshonpe ickshonpe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there are some fundamental problems with the way queuing is structured, both here and on main:

  • The UI doesn't support render layers and each UI entity can only have a single camera target. Instead of extracting all the object data into a single huge list, the objects should be stored in per camera target sublists.
  • UI objects are layered and flat, atm we update on all sorts of changes that shouldn't affect their ordering. Phase items should be requeued only on changes to their corresponding render object's local stack ordering, visibility, and camera target.
  • For uinodes on the same view, root uinodes should only be ordered relative to other roots, and the non-root uinodes should only be ordered relative only to other non-roots belonging to the same root. UI objects should be grouped together by main entity and ordered in fixed local layers.
  • It would take some wrangling, but it looks like it should be possible to consolidate everything into a single queue function. Then it could walk top-down: extracted views -> roots of each UI tree on that view -> changed main node entities for that tree -> each class of UI render object in the fixed within node z layer order.

Fine with merging this first though, it's still a substantial improvement on main, then exploring further improvements in follow up PRs

) where
E: UiRenderObject,
<E::SpecializedRenderPipeline as SpecializedRenderPipeline>::Key: Send + Sync,
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it will make that much difference, but maybe we should have an initial early out check here that returns if the buffers are all empty.

@pcwalton pcwalton closed this Aug 12, 2026
@github-project-automation github-project-automation Bot moved this from Needs SME Triage to Done in UI Aug 12, 2026
@github-project-automation github-project-automation Bot moved this from Needs SME Triage to Done in Rendering Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Rendering Drawing game state to the screen A-UI Graphical user interfaces, styles, layouts, and widgets C-Performance A change motivated by improving speed, memory usage or compile times S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Done
Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants